今天接續昨天的內容,要繼續實作註冊帳號時的 Email 驗證功能。
昨天我們已經梳理過流程,說明了要修改與新增的內容,並且也完成了前置作業(申請應用程式密碼與引入套件)
現在讓我們著手完成這個功能吧~
我們先到 AuthService 中新增呼叫 /register 與 /verify-email 端點的方法。再到 auth package 底下新增 register 與 verify-email 元件處理對應的畫面與邏輯。
(因為到目前為止都聚焦在後端的開發,前端頁面只是方便實作某些需要前後端配合的功能,因此針對前端程式碼的處理都會比較簡略,暫且忽略大部分檢核與錯誤處理。)
在 AuthService 中新增呼叫 /register 與 /verify-email 端點的方法:
export class AuthService {
	...
	// 呼叫後端 API 來註冊一個新使用者。
  register(registrationData: RegistrationRequest): Observable<RegistrationResponse> {
    const backendApi = `${environment.apiBaseUrl}/users/register`;
    return this.http.post<RegistrationResponse>(backendApi, registrationData);
  }
	// 呼叫後端 API 來驗證 Email Token。
  verifyEmail(token: string): Observable<VerificationResponse> {
    const backendApi = `${environment.apiBaseUrl}/users/verify-email`;
    return this.http.post<VerificationResponse>(backendApi, { token });
  }
}
關於傳入參數:RegistrationRequest 根據我們目前後端的設計,註冊僅需 email 與密碼;驗證email 則僅需提供 token 給後端進行驗證。
透過 Reactive Form 來建立一個簡單的註冊表單。
register.html
註冊頁面包含 email 、密碼欄位與登入按鈕,此處透過 FormGroup 搭配 FormControl 來接收表單輸入值:
<div class="register-container">
  <h1>註冊頁面</h1>
  <form [formGroup]="registerForm" (ngSubmit)="onSubmit()">
    <div class="form-group">
      <label for="email">Email</label>
      <input id="email" type="email" formControlName="email" >
    </div>
    <div class="form-group">
      <label for="password">密碼</label>
      <input id="password" type="password" formControlName="password" >
    </div>
    <button type="submit" [disabled]="registerForm.invalid">註冊</button>
  </form>
</div>
register.ts
若是註冊成功,此處以 Alert 提示使用者需要到信箱啟用帳號,並將用戶導回登入頁:
export class RegisterComponent {
  registerForm: FormGroup;
  constructor(
    private fb: FormBuilder,
    private authService: AuthService,
    private router: Router
  ) {
	  // 建立表單以接收輸入值
    this.registerForm = this.fb.group({
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required, Validators.minLength(8)]],
    });
  }
  get email() {
    return this.registerForm.get('email');
  }
  get password() {
    return this.registerForm.get('password');
  }
  onSubmit(): void {
    // 呼叫 AuthService 中的註冊方法
    this.authService.register(this.registerForm.value).subscribe({
      next: () => {
        alert('註冊成功!請檢查您的信箱以啟用帳號。');
        this.router.navigate(['/auth/login']);
      }
    });
  }
建立一個簡單的驗證頁面如下:
verify-email.html
<div class="container">
  <div *ngIf="status === 'verifying'">
    <p>正在驗證您的 Email,請稍候...</p>
  </div>
  <div *ngIf="status === 'success'">
    <h2>帳號啟用成功!</h2>
  </div>
</div>
verify-email.ts
這個頁面的元件在初始化時 ,會從 URL 中解析出 token 參數,並向後端的 /verify-email 端點發起請求,並根據請求結果渲染頁面:
export class VerifyEmailComponent implements OnInit {
  status: 'verifying' | 'success'  = 'verifying';
  constructor(
    private route: ActivatedRoute,
    private router: Router,
    private authService: AuthService
  ) { }
  ngOnInit(): void {
    const token = this.route.snapshot.queryParamMap.get('token');
    if (token) {
      this.authService.verifyEmail(token).subscribe({
        next: () => {
          this.status = 'success';
        }
        // TODO:未來根據後端回傳進行錯誤處理,若 token 過期要可以重發驗證信。
      });
    } else {
      this.router.navigate(['/auth/login']);
    }
  }
}
這個章節將依序說明以下實作:
verify-email 方法/verify-email端點。VerificationToken Entity
預期 VerificationToken 應包含 token 字串、UserEntity 的關聯 (外鍵),以及一個 expiryDate (過期時間)。
@Entity
@Getter
@Setter
@NoArgsConstructor
public class VerificationTokenEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false)
    private String token;
    // 一個 Token 只對應一個使用者
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(nullable = false, name = "user_id")
    private UserEntity user;
    @Column(nullable = false)
    private Instant expiryDate;
    public boolean isExpired() {
      return this.expiryDate.isBefore(Instant.now());
    }
    public VerificationTokenEntity(String token, UserEntity user, Instant expiryDate) {
      this.token = token;
      this.user = user;
      this.expiryDate = expiryDate;
    }
}
VerificationTokenRepository
新增 VerificationTokenEntity 的 Repository 好讓我們可以在資料庫中新增資料,新增 findByToken 方法以供後續驗證 Email 的 API 使用:
public interface VerificationTokenRepository extends JpaRepository<VerificationTokenEntity, Long> {
    Optional<VerificationTokenEntity> findByToken(String token);
}
建立一個新的 Service,當中處理包含與 Email 相關的服務。
EmailServiceImpl
新增 sendVerificationEmail 方法來實作寄送驗證 Email 的方法:
@Service
public class EmailServiceImpl implements EmailService {
    private static final Logger logger = LoggerFactory.getLogger(EmailService.class);
    @Autowired
    private JavaMailSender mailSender;
    @Value("${frontend.base.url}")
    private String frontendBaseUrl;
    @Async // 標記此方法為非同步執行
    public void sendVerificationEmail(UserEntity user, String token) {
        try {
            // 建立郵件內容
            SimpleMailMessage email = new SimpleMailMessage();
            email.setFrom("Food Print Service<xxxx@gmail.com>"); // 指定寄件人名稱
            email.setTo(user.getEmail());
            email.setSubject(" 【Food Print Service】請驗證您的 Email 以啟用帳號");
            String message = buildVerificationMessage(user, token);
            email.setText(message);
            // 寄送郵件
            mailSender.send(email);
            logger.info("已成功寄送驗證信至:{}", user.getEmail());
        } catch (MailException e) {
		        // 非正式的錯誤處理
            logger.error("寄送驗證信至 {} 時發生錯誤", user.getEmail(), e);
        }
    }
    private String buildVerificationMessage(UserEntity user, String verificationToken) {
        String confirmationUrl = frontendBaseUrl + "/auth/verify-email?token=" + verificationToken;
        return String.format(
                "您好,\n\n" +
                        "感謝您註冊 Foot Print Service,請點選下方連結啟用您的帳戶:\n\n" +
                        "%s\n\n" +
                        "若未於本網站註冊,請忽略此信。",
                confirmationUrl
        );
    }
}
UserEntity
最初我們實作 UserDetails,簡單讓 isEnabled 方法固定回傳 true。現在讓我們加入 enabled 欄位,讓相關服務可以透過 setter 來改變帳號的驗證狀態:
public class UserEntity implements UserDetails {
		...
    @Column(nullable = false)
    private boolean enabled = false;
    @Override
    public boolean isEnabled() { return this.enabled; }
}
register
回到 AuthService,在先前開發的 register 方法中新增兩個環節:
(1) 新建用戶時將驗證狀態設為停用
(2) 建立驗證 Token ,並加入寄發驗證連結。
@Transactional
  public UserEntity registerUser(RegistrationRequest registrationRequest){
      if (userRepository.findByEmail(registrationRequest.email()).isPresent()) {
          throw new IllegalStateException("該信箱已被註冊。");
      }
      UserEntity user = new UserEntity();
      user.setEmail(registrationRequest.email());
      user.setPassword(passwordEncoder.encode(registrationRequest.password()));
      // (1)建立用戶時將驗證狀態設為停用
      user.setEnabled(false);
      UserEntity savedUser = userRepository.save(user);
      // (2)建立驗證 Token 並呼叫非同步方法寄送驗證 Email
      String token = UUID.randomUUID().toString();
      VerificationTokenEntity verificationToken = new VerificationTokenEntity(token, user, Instant.now().plusMillis(refreshTokenExpirationMs));
      verificationTokenRepository.save(verificationToken);
      emailService.sendVerificationEmail(user, token);
      return savedUser;
  }
verifyEmail
在 AuthService 中新增 verifyEmail 的方法驗證 token 有效性:
public void verifyEmail(String token) {
    // 1. findByToken
    VerificationTokenEntity verificationToken = verificationTokenRepository.findByToken(token)
        .orElseThrow(() -> new TokenNotFoundException("無效的驗證 Token"));
    // 2. 檢查 Token 是否過期
    if (verificationToken.isExpired()) {
        verificationTokenRepository.delete(verificationToken); 
        throw new TokenExpiredException("此驗證 Token 已過期");
    }
    // 3. 使用者狀態設定為啟用
    UserEntity user = verificationToken.getUser();
    user.setEnabled(true);
    userRepository.save(user);
    // 4. 刪除已使用的 Token
    verificationTokenRepository.delete(verificationToken);
}
此處自訂了 Exception,並在 GlobalExceptionHandler 定義例外處理,前面實作有過類似的說明就不附上內容了。
AuthController
我們開立 /verify-email 端點提供前端驗證 Eaill 使用:
public class AuthController {
		...
    @PostMapping("/verify-email")
    public ResponseEntity<?> verifyEmail(@Valid @RequestBody VerificationRequest verificationRequest) {
        authService.verifyEmail(verificationRequest.token());
        return ResponseEntity.ok().build();
    }
}
在此簡單附上功能測試成功的幾個主要畫面。
於註冊頁面註冊成功後,提醒用戶至信箱啟用帳戶。
至信箱查看寄發之驗證信內容。
點擊信箱內的驗證連結,導回驗證頁面並顯示驗證結果。
透過今天簡單的實作,我們完成了在建立帳號時寄送驗證 Email 的服務。
不過既然我們設計 token 都有考量到過期的狀況,就表示應該有個重發驗證信的機制,今天的內容本來有包含重新寄發驗證信的功能,但考量到篇幅與對應的處理會使整篇文章更冗贅,還是先行移除了,著重在實作 Email 驗證功能上。